You can read this post in English.

 

みなさんこんにちは、FRESH! でフロントエンドの開発している鈴木(sutiwo)です。

前回は、FRESH!におけるPCブラウザのFlash脱却という HLS の Web プレイヤーについて記事を書きました。

今回はスマートフォン・ PC でのブラウザに関するパフォーマンス改善の取り組みとその結果についてお知らせします。

まずクライアントサイドのパフォーマンス改善を行うにあたり、弊社の Web Initiative Center* から 1000ch 氏に加わっていただきどのようなことを目標とするか議論しました。

* Web プロダクトの品質向上とWeb技術を使ったチャレンジを目的に設立された弊社の組織

FRESH! フロントエンジニア

議論の様子

議論を行った後、今回の改善で以下のことに取り組むことが決まりました。

  1. Service Worker で静的アセットのキャッシュ
  2. Intersection Observer を用いた遅延ロードでモバイルサイトのロード改善
  3. SVG スプライトやめてプログレッシブなロード

それでは、各内容について説明していきたいと思います。

Service Worker で静的アセットのキャッシュ

Service Worker とは

Web ページと別にバックグラウンドで実行するWeb Workerの一種です。ネットワーク処理のハンドリングに加えて、プッシュ通知の受信やバックグラウンド同期などWebに長らく求められてきた機能を実現します。特徴や実装の注意点として以下のようなものがあげられます。

  • Web Workerの一種なのでDOM 操作ができない、ブラウザとはpostMessageでやりとりする
  • 配信には https が必須、localhost は例外として認められている

今回はその Service Worker の機能の1つである fetch イベントのフックを行い、リクエスト時に Service Worker でキャッシュの有無を確認し、あればローカルキャッシュ、なければ外部からリクエストするという仕組みを使い静的アセットのキャッシュ管理を行います。

設計方針と実装方法

キャッシュの更新設計は、サービスで使用している静的アセット( Web フォントや CSS ファイル、 SVG アイコン)などをリリース単位でキャッシュさせるもの(下記画像では v1490957780 )*1と意図的に更新させないと変わらないバージョン(下記画像では v20170321 )*2のようにしました。

また、下記のように3つのファイルにわけて管理上見通しをよくし、最終的にビルドした service-worker.js をルートに置きました。

service-worker
├── assets.js
├── index.js
└── register.js

assets.js ではWebフォント、CSS・JS ファイルなどをどのようにキャッシュ管理するかのホワイトリストを作成し、index.js ではイベントハンドラの登録、register.js では Service Worker がインストールされているかの判定を行います。


/**
 * index.js
 */
import { VENDORS, FONTS, ASSETS } from './assets';

// CircleCIでビルドされたタイムスタンプがbuild_idとして環境変数で渡ってくるバージョン (リリース毎に切れる比較的弱めなキャッシュ) *1
const assets = require('../../assets'); 
// 意図的に更新させないと変わらないバージョン (比較的強めなキャッシュ) *2
const version = require('../../version');

const CACHE_KEYS = [
  `fresh-vendor-v${version.vendors}`,
  `fresh-fonts-v${version.fonts}`,
  `fresh-assets-v${assets.build_id}`
];

self.addEventListener('install', e => {
  e.waitUntil(self.skipWaiting());
});

self.addEventListener('activate', e => {
  // アクティベートされたときにCACHE_KEYSにマッチしないキャッシュを削除する
  const deletion = caches.keys()
    .then(keys => keys.filter(key => CACHE_KEYS.indexOf(key) === -1))
    .then(keys => Promise.all(keys.map(key => caches.delete(key))));

  e.waitUntil(deletion.then(() => self.clients.claim()));
});

self.addEventListener('fetch', e => {
  const url = e.request.url;

  // 対象ではないファイルはアーリーリターンでフォールバックする
  if (!VENDORS.some(file => url.includes(file)) &&
      !FONTS.some(file => url.includes(file)) &&
      !ASSETS.some(file => url.includes(file))) {
    return;
  }

  // ストレージからキャッシュを探す
  const cache = caches.match(e.request).then(response => {
    // 見つかった場合はそのままブラウザへ返す
    if (response) {
      return response;
    }

    // 見つからなかった場合はリクエストする
    return fetch(e.request.clone()).then(response => {
      if (response.ok) {
        const clone = response.clone();
        const cacheKey = getCacheKey(url);
        caches.open(cacheKey).then(cache => cache.put(e.request, clone));
      }

      return response;
    });
  });

  e.respondWith(cache);
});

これで初回アクセス時対象のファイルがキャッシュされ、2回目以降のアクセスではプロキシ経由でのローカルファイルが参照されるようになります。

デバッグ方法

実際に Service Worker の導入をすすめていくと、ページの表示時に Service Worker が正しくインストールされているのか、また指定した静的アセットが意図したとおりキャッシュされているのか確認する必要があります。Chrome DevTools を使うとそれらの確認がスムーズにでき便利です。

Application タブの Service Workers メニュー

Application タブの Service Workers メニューをクリックすると訪れているサイトスコープで Service Worker の状態を確認することができます。上記の画像では Status のインジケータランプが緑色になっており service-worker.js が有効になっていることがわかります。ちなみに右上の Show All にチェックを入れると訪れたサイトの登録されている Service Worker が一覧で表示できます。自分が訪れたサイトの中でどのようなサイトが Service Worker を使っているかを確認してみるのも興味深いです。

Application タブの Cache Storage メニュー

Application タブ内にある Cache Storage メニューは開閉式になっています。クリックすると Service Worker によってキャッシュされたファイル確認することができます。上記のコードで分けた `CACHE_KEY_*` に従ってリクエストファイルが表示されています。

Status 200 from Service Worker

NetworkタブのHeaders (Nameに表示されるファイルをRegexで絞っている)

Network タブの Headers では libs.js へリクエストした Status Code が 200 と表示されていますが、その後ろに (from Service Worker) と記されています。これこそがリクエストのプロキシを行っている証拠で、実際に HTTP リクエストをせずファイルをキャッシュから呼び出しています。

簡単に Chrome DevTools でのデバッグ方法をお伝えしましたが、Progressive Web App のデバッグ | Web | Google Developersではより詳しく説明されているので興味のある方はご覧になってみてください。

結果を比較

上記の動画では、Service Workerに対応していない iPhone7 Safari (画面左)と対応している Nexus 5X Chrome (画面右)で Google 検索画面からの遷移表示比較を行ったものです。結果が顕著にわかるようそれぞれ3Gのキャリア回線を使用し検索画面でタッチエンドの直後から計測をはじめ、ヒーローイメージが表示された地点で時間を止めています。

動画の通り、Service Workerに対応していない場合はヒーローイメージまでの表示に4.22秒かかっているのに対し、Nexus 5X は2.10秒で済んでいます。なお、数値は数回計測した中のおおよその中央値です。

ヒーローイメージ以外にもこのページのファーストビューにはSVG アイコンやストアへのアプリダウンロード画像など Service Worker を使ってキャッシュしている画像が多く、全体的に表示が早いことがわかります。

実際ヒーローイメージが表示されるまでの時間がどのようになっているか、 Good 3g に throttle して devTools で確認してみます。

Service Worker を使った場合のヒーローイメージのTiming

CDNにリクエストした場合のヒーローイメージのTiming

2つの画像を比べると一目瞭然ですが、Content Download の時間は ServiceWorker を使用している時で 0.00066秒 (数回計測した中でのベストエフォート)に対して、実際にダウンロードをしている場合は 1.47秒かかっています。今回のヒーローイメージのサイズは約50kbですが、ファイルサイズが大きければより顕著な差がでることでしょう。

もちろん、これらの数字はネットワーク速度や端末の CPU (Service Workerの処理を行う)の性能によって変わってくるのであくまで目安としていただければと思います。

 

Intersection Observerを用いた遅延ロードで画像表示を改善

Intersection Observer とは

特定のDOM要素が画面内に入っているかどうか、さらにその位置までも容易に得ることができるのAPIです。

後述する従来の方法と比べ、スクロールによるDOM要素の出現などを効率よく検知することが可能となりました。

なぜ使うのか

スマートフォンではおなじみのリスト型やフィード型のレイアウトをFRESH!でも採用しています。一度にたくさんの番組リストを表示するとどうしてもサムネイルのリクエスト数が増えてしまいます。

これらのサムネイル画像をスクロールをトリガーとして非同期ロードをすれば無駄なリクエストの削減(そもそもユーザーがファーストビューだけで遷移する可能性もある)にもつながるのでメリットは大きいでしょう。

ところが、もしIntersection Observerを使わずにこのようなUIを実装するとなると、スクロールイベントが頻発させないよう間引く必要があります。併せて、要素がどこにあるかを判定する際に使用する scrollTop, offset, getBoundingClientRect() はForced Synchronous Layout (最新のレイアウト情報を取得するためにレイアウト処理を強制的に実行させる) が起こってしまうというデメリットもありました。従ってその場合は、throttleやdebounceよる間引きで実行間隔の調整を行うことが大事です。詳しくは What forces layout/reflow. The comprehensive list. に書かれています。

実装方法

FRESH!のWebアプリケーションに関する全体的なアーキテクチャとしてIsomorphic(SSR + SPA)をReactでレンダリングする考え方を取り入れています。詳しくはIsomorphic Architecture を実装してるときの細かいアレコレ ::ハブろぐに書かれています。

 `Image/index.js` はUIコンポーネントの画像描画部分を担っており、 `componentDidMount` 内でブラウザがIntersection Observerをサポートしていればインスタンスを生成し交差処理の監視を始めます。

その際ポイントになるのがrootMarginオプションを設定することです。rootMarginを第二引数に入れることで、表示領域に到達する少し前に画像を取得し、あたかも画像が既に読み込まれていたかのような体験をユーザーに提供することができます。ちなみにrootMarginの設定はCSSのmargin省略方法と同じで、下記の場合は上下200px 左右0となります。

画像を取得したら描画させるためReactのsetStateを用いてコンポーネントを更新させます。


/**
  * Image/index.js
  */
'use strict';

const BLANK_IMAGE = 'data:image/gif;base64,=';
const ROOT_MARGIN = '200px 0px';

export default class Image extends React.Component {
  static propTypes = {
    src : React.PropTypes.string
  };

  state = { src : BLANK_IMAGE };

  intersectionObserver;

  startObserve() {
    this.intersectionObserver = new IntersectionObserver(entries => {    
      if (this.state.src === BLANK_IMAGE) {
        this.intersectionObserver.unobserve(this.refs.img);
        this.setState({ src : this.props.src });
      }
    }, {
      rootMargin : ROOT_MARGIN
    });

    this.intersectionObserver.observe(this.refs.img);
  }

  componentDidMount() {
    this.startObserve();
  }

  componentWillReceiveProps(nextProps) {
    if (nextProps.src !== this.props.src) {
      this.setState({ src : nextProps.src });
    }
  }

  componentWillUnmount() {
    if (this.intersectionObserver) {
      this.intersectionObserver.unobserve(this.refs.img);
      this.intersectionObserver = null;
    }
  }

  render() {
    return (
      <img src={this.props.src} />
    );
  }
}

実際には上記のようなコードに加えて、CDNへの画像パラメータの付与(幅・高さ・crop・pixelRatioの対応)や画像エラー時のハンドリング、altの付与など一般的な処理が加わります。

現状スマートフォンではChrome for Androidでしか有効でないことを踏まえてプログレッシブ・エンハンスメントという考え方ではなく、公式のPolyfillを併用する方針をとりました。

結果

スクロールにより画像が非同期に読み込まれていますが、 rootMargin のおかげで描画が実にスムーズになっていることがわかります。直前ではなく、あたかも事前に読み込んでいたかのような挙動をしています。

LayzyLoadのスクリーンキャスト

スクロール時のスクリーンキャスト

FRESH!のSPEED CURVE CONTENTS REQUESTのデータ
FRESH!のSPEED CURVE CONTENTS SIZEのデータ

before after
CONTENT REQUEST  80 40
CONTENT SIZES 1,500kb 900kb

定量的な数値では、 CONTENT REQUEST・CONTENT SIZES をともに半分へと削減することができました。 Fontsファイルは Service Worker でキャッシュさせるため Google Fonts から自社で使用している CDN に移行したので、Font Size が増加しています。

不必要なリクエストを避け初期表示を素早く行えるようになったことで、エンドユーザーとサービス側両方にメリットが生まれました。

 

SVG スプライトやめてプログレッシブなロード

SVG スプライトとHTTP2

これまでアイコンはSVGファイルを結合させ使用していました。

ところが、 FRESH! で利用している ALB は HTTP/2 に対応していることで、 HTTP/1.1 のときに課題だったリクエストのコストを通信多重化や並行リクエストにより軽減させることができました。したがって、 SVG ファイルの結合によるメリットは小さくなりました。
むしろ、スプライトして一つのファイルにすると全体がダウンロードされるまでレンダリングされず、アイコンだけが遅れて表示されることがありました。

結果どのようになったか

スプライトをやめることでブラウザは各アイコンの SVG ファイルをダウンロードし次第、逐次評価を行いレンダリングを開始するようになりました。その結果、画面上部のサービスロゴや視聴ページの再生ボタン内のアイコンなど、重要なアイコンが遅れて表示されることが少なくなりました。

 

まとめ

今回のパフォーマンス改善の取り組みで Speed Curve での Start Render と SpeedIndex の速度がともにリリース前後で1秒程度早くなりました。下記の画像では 20160316_01 を境にグラフが下がっているのがわかります。

レンダリング速度の変化

RENDERING の推移

メンバー一同日々運用しているサービスに上記のようなわりと大きな機能改善を組み込めたのはよかったと太鼓判を押しています。これらの改善がスムーズにできたことは一重に FRESH! が比較的新しいサービスで、モダンな環境が整っていたこともあると思います。( Service Worker の https 必須条件など)

SppedIndexのグラフ

他社の動画サービスとの比較

今後は長期的にパフォーマンス改善の目安となる Speed Index などのスコアをキャッチアップしていきつつ、Web のエコシステムにチーム全体で思いを馳せてゆきたい所存です。エンドユーザーが普段使っているサービスと比較し「 FRESH! は速くて使いやすいな!」という体験を得られるよう0.1秒、いや0.01秒でも速く表示できるよう SSR 時のレンダリングスピード改善にも着手していく予定です。

最後に、今回の記事でみなさんが日頃開発・運用されているプロジェクトの参考に少しでもなれば嬉しいです。

 

FRESH!(フレッシュ) – 生放送がログイン不要・高画質で見放題